Skip to content

ForceFreeStates.Galerkin - FEATURE! - Solve the Galerkin system by banded Cholesky, with LU fallback - #513

Merged
matt-pharr merged 21 commits into
developfrom
feature/galerkin-cholesky
Oct 27, 2026
Merged

matt-pharr merged 21 commits into
developfrom
feature/galerkin-cholesky

Conversation

@StuartBenjamin

@StuartBenjamin StuartBenjamin commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Release note

  • Audience: users
  • Numerical impact: (harness @ a1efcd6) After merging develop 6d655c74c, all 63 post-merge runs completed successfully (21 cases × develop 6d655c74c, pre-merge PR c00ede00e, merged head a1efcd6f7). Compared with c00ede00e, 20 cases are unchanged within thresholds and the driven-PE case retains eight N/A quantities; no quantities are flagged changed by the merge. In the preceding coefficient-change comparison (b75eeb3a8 → c00ede00e), 18 cases are unchanged within harness thresholds, two Galerkin cases each have five changed quantities, and the driven-PE case retains eight N/A quantities on all refs. In both Galerkin cases, the coefficient correction changes the PEST-3 Δ′ diagonal by up to 12.16%, the raw Δ′ matrix norm by +5.38%, the coil-block norm by −93.51%, matched cout norm by −0.43%, and matched bpen norm by −67.06%. These are material driven-response changes requiring reviewer attention, not a claim of independently validated absolute accuracy.
  • Migration: gal_solver defaults to "auto", which attempts Cholesky and falls back to scaled LU. Explicit "cholesky" requires Cholesky to succeed and errors otherwise; "LU" skips the attempt. Selecting LU does not undo the Frobenius or coefficient corrections. extended_precision_bvp retains default true; false remains an explicit Float64 option. Re-baseline affected Galerkin Δ′ and driven-response results.

The combined change implements banded Cholesky with checked failure and LU fallback, preserves nonzero Dirichlet boundary values, and retains the trace-aware Frobenius exponent correction. Galerkin now reconstructs F̄=LL† from the same interpolated factor L used by the Euler–Lagrange kernel and singular expansions, rather than independently interpolating F̄. Existing buffers are reused for the reconstruction.

Implementation and focused validation

Published head: a1efcd6f70b0d888b90415de6da191e7af704146. Develop 6d655c74c was merged without conflicts; all 29 merged Julia files parse and whitespace checks pass. The worktree is clean. The full harness was rerun at this merged head.

  • The Cholesky guard checks finite entries and conjugate agreement of free-block off-diagonals to 1e-10 * sqrt(abs(A_ii)) * sqrt(abs(A_jj)). It deliberately projects the diagonal onto its real part; this is not a full-matrix Hermiticity test.
  • Fallback is logged even with verbose=false; unequal bandwidths receive a structural diagnostic. The actual factorization is recorded under ForceFreeStates/Solutions/GalerkinIntegration/solver.
  • Double64 remains the BVP default, including no-control callers. Tests cover complex/real/imaginary results and an independent 256-bit reference.
  • The resonant-trace warning is emitted once per surface per solve. It diagnoses trace consistency, not radial/Fourier convergence.
  • The latest focused package runs passed 27 solver tests and 71 coefficient tests per example. Coefficient tests exercise between-knot reconstruction, rational surfaces, Hermitian positive semidefiniteness and buffer reuse.

Δ′ comparison and deferred accuracy investigation

The comparison is the full complex PEST-3 matrix norm norm(Δ′_Gal - Δ′_Ric)/norm(Δ′_Ric), including off-diagonal coupling. Both methods use the same equilibrium, Fourier band, surface ordering and fixed displacement boundaries; Riccati uses the extended-precision BVP. These comparisons do not establish Riccati as an exact reference.

Aligning the coefficient representation reduces the DIII-D discrepancy at nx=256 from about 10.98% to 0.152%. Across the expanded battery, LU and Cholesky agree within 2.53e-9 relative Δ′ matrix norm.

Elements per interval DIII-D Galerkin–Riccati gap LAR gap
256 0.151700% 0.00290402%
512 0.147676% 0.00230286%
1024 0.145063% 0.00206664%

An 11-Galerkin/6-Riccati-variant battery per equilibrium varied radial resolution, matching widths, expansion order, quadrature, Riccati tolerance and crossing distance. A separate eight-variant study isolated the resonant cutoff, larger quadrature budgets and spline-knot splitting. Expansion order barely affects the residual, and Riccati-control effects are much smaller than it. Cutoff/budget changes do not explain the gap. All 96 instrumented resonant integrals exhausted their quadrature budgets without meeting estimated error targets; increasing the budget 40× barely changes Δ′. DIII-D's resonant intervals contain no interior spline knots, so splitting there was a no-op.

The remaining discrepancy, quadrature stopping behavior, and ordinary/extension-cell knot-crossing investigation are explicitly deferred to #603 by maintainer agreement. The outermost DIII-D surface dominates the matrix norm. Full numerical agreement has not been demonstrated.

Factorization timing

Physical free-boundary assemblies at nx=256, without RPEC coil columns. Medians of seven warmed samples, alternating solver order; assembly and input copies excluded. Auto includes the guard, Cholesky attempt and any LU fallback.

Case Direct LU Auto Verified factorization path
DIII-D 0.5403 s 0.8982 s Cholesky factorization fails, then LU; +0.3579 s (+66.24%)
LAR 0.3221 s 0.2991 s Cholesky succeeds; 7.15% faster

DIII-D's resulting Δ′ matrices are identical between direct LU and fallback. LAR's relative difference is 7.74e-11. Auto is not universally faster: known indefinite systems can select "LU" directly.

Final post-merge regression harness: a1efcd6

21 cases × three refs: develop 6d655c74c522313084beb3807a8f71a78f28785a, pre-merge PR c00ede00e538596f10ef0fb4e791de4c11d3e6c3, merged head a1efcd6f70b0d888b90415de6da191e7af704146. All 63 case runs, preparation and report completed successfully; report exit code 0. Julia 1.12.6, CI-pinned manifest 4c9d54fa, Slurm fanout, matching per-case resources across refs.

Compared with the pre-merge PR, 20 cases are unchanged within harness thresholds, including both Galerkin cases. gal_resistive_pe retains eight N/A quantities on all refs. No tracked quantities are flagged changed by the develop merge. The material coefficient/driven-response changes established below remain part of the combined PR; the N/A case remains a coverage limitation.

Coefficient-change harness before the develop merge: c00ede0

21 cases × three refs, Julia 1.12.6, CI-pinned manifest 4c9d54fa, executed through Slurm fanout. All tasks and the final report completed successfully; report exit code 0. Per-case resources were identical across refs, including the separate precompiled Riccati case.

Quantity (both Galerkin cases) develop 6d655c74c Previous PR b75eeb3a8 Final c00ede00e
Raw Δ′ matrix norm 12438.12 2184.432 2302.048
Coil-block norm 243.7580 243.7137 15.81714
Matched cout norm 0.03062036 0.03061584 0.03048397
Matched bpen norm about 3.778795 3.661452 1.206218

Surface locations, D_I, α and inner-layer Δ remain unchanged by the final coefficient commit. Matched residuals remain near 1.7e-16. The harness flags the five changed quantities in each Galerkin case; an exit code of zero does not mean all numerical results are unchanged. The driven-PE case's eight missing quantities remain a validation limitation.

Review status

The implementation, convergence battery, fallback timing and final harness are complete. Residual accuracy investigation is tracked in #603. The large coil/driven-response changes above remain part of the human review scope.

Third-party human reviewer approval is required before merging into develop.

Historical PR body and investigation reports (superseded by the final results above)

Release note

  • Audience: users
  • Numerical impact: (harness @ 633f9bd) The last complete harness compared develop 4c4126738, the previous PR head 74843b814, and review-fix head 633f9bd33. The combined changes reduce the Galerkin raw Δ′ matrix norm from about 12438 to 2184 and change matched bpen norms by 3.11%. The review fixes leave tracked Galerkin quantities unchanged against 74843b814; Riccati diagonal differences are about 1.6e-10, and SLAYER growth rates change by 0.02%. This stamp predates the latest develop merges. The final harness will run after the remaining accuracy and timing work is finished.
  • Migration: gal_solver now defaults to "auto" (Cholesky first, then LU); set "LU" to select the previous factorization. That selection does not undo the Frobenius exponent correction. extended_precision_bvp retains its previous default true, including for callers without a control object; false remains an explicit Float64 option. Results pinned to the old imaginary Δ′ or Galerkin alpha need re-baselining.

The Galerkin solver now uses working banded Cholesky wrappers, checks factorization failure, and falls back to scaled LU. Dirichlet columns are transferred to the RHS, preserving nonzero coil boundary values. The trace-aware Frobenius exponents from #517 remain in this PR: each side uses the eigenvalues of its own resonant M₀ block, with consistent root ordering on Mercier-unstable surfaces.

Current implementation and integration status

  • Published head: b75eeb3a8, incorporating develop e0324a91d (including the SLAYER transport-model updates). This merge was clean; all 22 merged Julia files parse and whitespace checks pass. The convergence measurements below remain measurements at 3d3e723ef.
  • The Cholesky guard checks finite entries and conjugate agreement of the free block's off-diagonals to 1e-10 * sqrt(abs(A_ii)) * sqrt(abs(A_jj)). The diagonal is deliberately projected onto its real part; this is not a full-matrix Hermiticity test.
  • LU fallback is reported even with verbose=false. Unequal bandwidths get a structural diagnostic. The factorization actually used is written to ForceFreeStates/Solutions/GalerkinIntegration/solver.
  • Double64 remains the default for the BVP and PEST3 combination. Tests cover the whole complex matrix and individual real/imaginary components, plus an independent 256-bit reference for the no-control path.
  • The resonant-trace warning is emitted once per surface per solve, from the right-side calculation. Its documented threshold is abs(tr(M₀))/2 > max(1e-3*abs(α), 64*eps(Float64)*opnorm(M₀, Inf)). It diagnoses trace consistency, not radial/Fourier convergence.
  • At published head 3d3e723ef, GitHub's Julia 1.11 and latest-Julia test suites, aggregate Tests check, and documentation pass. The release-note check remains stale until the final harness stamp is available.

Convergence and successful-solve timings at 3d3e723

Completed on ITER SDCC (Slurm array 1296767, both tasks exited 0), Julia 1.12.6, pinned manifest 4c9d54fa, four Julia threads and one BLAS thread per task.

Both shipped examples were evaluated at gal_nx = 128, 256, 512. LU and Cholesky-first used identical assembled matrices and RHSs. The Riccati references used the same equilibrium, Fourier band, surface ordering, and fixed outer displacement boundary condition.

At gal_nx = 256:

Case Relative complex Δ′ matrix difference, Cholesky vs LU Relative change from nx=256 to 512 LU solve Cholesky-first solve
DIII-D-like 8.98e-6 about 3.50e-5 for either solver 1.049 s 0.912 s
LAR 9.60e-10 about 1.19e-5 for either solver 0.942 s 0.804 s

Solver differences are norm(Δ′_auto - Δ′_LU)/norm(Δ′_LU); refinement differences use each solver's nx=512 result as the denominator. Timings are medians of three warmed solves, excluding input copies and assembly, and including the guard, factorization, and multi-RHS solve. Cholesky was actually used in all six Galerkin runs. The largest DIII-D-like relative change in a real diagonal entry between solvers at nx=256 is 2.65e-7.

Accuracy limitation: the Galerkin–Riccati discrepancy remains about 11% on DIII-D-like and 0.0558% on LAR at nx=512. At DIII-D-like q=5 and nx=256, LU gives approximately −2703.9317 + 25.1123i, Cholesky gives −2703.9324 + 25.0880i, and Riccati gives −3036.3550 + 2.1e-5i. Refining only the Galerkin element count does not remove this difference. These results establish close LU–Cholesky agreement on the tested systems; they do not establish which formalism is closer to the continuum result.

Remaining validation before the final harness

  • Explain the common Galerkin–Riccati discrepancy, or explicitly agree on its disposition as follow-up work. Do not treat the Riccati result as a verified converged reference yet.
  • Measure the cost of failed Cholesky followed by LU on an indefinite free-boundary system. The successful-solve timings above do not cover that path.
  • The latest develop merge is complete. Run the final regression harness after the remaining accuracy and timing work is complete, then update the release-note stamp and numerical-impact summary.

Last complete regression harness: 633f9bd

regress --refs 4c4126738f4a5d1a473b9814d4e661113542698f,74843b814f4677536f09b003b250f00248e5212a,633f9bd33568e23d22a7928f042d71dea5f88287

All 57 runs (19 cases at each of three refs) completed successfully on ITER SDCC with Julia 1.12.6 and the pinned CI manifest. The comparison of 633f9bd33 with 74843b814 reports 17 cases unchanged within their harness thresholds, one case with a 0.02% SLAYER growth-rate change, and gal_resistive_pe with all eight quantities N/A on both refs. Tracked Galerkin quantities are unchanged; the Riccati BVP diagonal's maximum absolute difference is about 1.6e-10. The 3.11% matched-bpen shift against develop remains part of the combined PR, not a new shift introduced by the review fixes.

Historical reports

The earlier reports below are retained as the investigation record. Their heads, defaults, and validation statuses describe those earlier revisions.

Earlier harness reports and measurements

Regression report

regress --refs 23ff553a5140d813f5d76549b70b3fcaa74b1769,74843b814 completed on ITER SDCC with Julia 1.12.6 and the pinned CI manifest 4c9d54fa. All 38 runs (19 cases at each ref) completed successfully. Most cases used 2 Julia threads / 1 BLAS thread; diiid_error_field used 16 Julia threads / 8 BLAS threads on both refs.

Case Current comparison
gal_resistive_diiid, gal_resistive_diiid_galinner Δ′ matrix norm 12438.12 → 2184.432 (82.44%); Δ_coil norm changes by 0.02%; matched cout norm by 0.01%; matched bpen norm 3.77879 → 3.66145 (3.11%). The bpen shift is recorded for review; this report alone does not isolate its cause.
diiid_n1_riccati, diiid_n1_riccati_precompiled Complex BVP Δ′ diagonal maximum difference 3.711 (0.16%); raw side-major diagonal difference 1.862 (0.02%). This aggregate comparison does not separate real and imaginary changes.
diiid_n1 Leading total, plasma and vacuum energy differences 1.36e-9, 3.84e-9 and 2.48e-9 absolute; four fewer ODE steps.
diiid_multi_n Forcing-driven Δ′ changes by 0.03%; resonant area-weighted field by 0.01%; two fewer saved ODE steps.
diiid_slayer_n1, efit_fixedbdy_separatrix, ggj_ray_q500i, ggj_reference, solovev_kinetic_calculated, solovev_kinetic_nuzero All tracked quantities unchanged.
gal_resistive_pe All eight tracked quantities are N/A on both refs, so this is not a numerical validation of the PE output.
Remaining cases Small changes as listed in the full report; no run failures.

The full Julia test suite at 74843b814 passed on ITER (Julia 1.12.6, 4 CPUs, Slurm job 1296033), with one test marked broken. GitHub's latest-Julia tests and documentation also passed. The cancelled Julia 1.11 job has been resubmitted.

Historical regression report

regress --refs 278057d96,c827d4d98 (develop vs this head, all 18 cases; julia 1.11.7, pinned manifest, 2 threads):

Case What moved
gal_resistive_diiid Im Δ′ at q = 5: −15182 → 24.9, so the Δ′ matrix norm goes 12442 → 2183. Re diagonal ≤ 6e-4 relative (−2700.25 → −2698.58 at q = 5)
diiid_n1_riccati (and _precompiled) Im Δ′ at q = 5: 286 → 5e-6. Re diagonal ≤ 1e-7 relative
diiid_n1, diiid_multi_n, diiid_truncation_n1, diiid_error_field energies, PE and torques ≤ 1e-6 relative; island widths and Chirikov ≤ 0.02 %; ODE steps −6 of 1431
solovev_n1, solovev_multi_n, solovev_kinetic_* Re energies ≤ 2e-5 relative (Im parts ~1e-5 absolute); NTV ≤ 0.06 %
diiid_slayer_n1 Q, ω, γ ≤ 0.17 %
the rest unchanged; gal_resistive_pe is N/A on both, as on develop

With regress --refs 278057d96,4b44dcd02,c827d4d98, the three review commits (a968945, 2c02244, c827d4d) change none of these numbers: every case reports c827d4d unchanged against 4b44dcd.

Galerkin Cholesky alone, before #517 was merged (6fc89d0 vs cd290c6)

regress --cases gal_resistive_diiid,gal_resistive_pe --refs 6fc89d021,cd290c659 (develop merge-base vs the Cholesky commit; julia 1.11.7, pinned manifest, 2 threads). gal_resistive_pe is N/A on both refs, as on develop.

Case: gal_resistive_diiid — DIII-D-like, n=1, RDCON outer-region Galerkin Δ′ with rpec coil columns (delta_coil)
================================================================

Regression Report: gal_resistive_diiid
=====================================================================================
Ref 1: 6fc89d021  @ 6fc89d021 (2026-10-09)
       env: julia 1.11.7, x86_64-linux-gnu, manifest fdfdedfa (pinned), 2 threads/2 BLAS, jit build
Ref 2: cd290c659  @ cd290c659 (2026-10-13)
       env: julia 1.11.7, x86_64-linux-gnu, manifest fdfdedfa (pinned), 2 threads/2 BLAS, jit build
-------------------------------------------------------------------------------------
Quantity                 6fc89d021     cd290c659     Diff               Status       
-------------------------------------------------------------------------------------
gal # singular surfaces  4             4             0.0e+00            OK           
gal singular q values    [4 elem]      [4 elem]      0.0e+00            OK           
gal PEST3 Δ diagonal     [4 elem]      [4 elem]      6.140e+01 (0.40%)  ** CHANGED **
||gal Δ′ matrix||        1.243825e+04  1.243849e+04  2.367e-01 (0.00%)  ** CHANGED **
||gal Δ_coil block||     2.498141e+02  2.498168e+02  2.686e-03 (0.00%)  ** CHANGED **
gal D_I per surface      [4 elem]      [4 elem]      0.0e+00            OK           
gal α per surface        [4 elem]      [4 elem]      0.0e+00            OK           
||gal inner-layer Δ||    1.474630e+06  1.474630e+06  0.0e+00            OK           
||gal match cout||       3.061595e-02  3.061584e-02  1.2e-07            OK           
gal match residual       1.901643e-16  2.612202e-16  7.1e-17            OK           
Runtime (s)              226.0s        185.4s                           --           
=====================================================================================
Summary: 3 changed, 7 unchanged

…r gal_solver = "cholesky"

gal_solver = "cholesky" called LinearAlgebra.LAPACK.pbtrf!/pbtrs!, which do not exist, so the
option died with an UndefVarError. Call LAPACK zpbtrf('L')/zpbtrs('L') directly through
libblastrampoline, unscaled, on the lower band the assembly already builds for Cholesky. Unlike
the reference implementation, a non-positive-definite matrix (zpbtrf info > 0) raises an error
naming gal_solver = "LU" instead of continuing with a partial factor.

Add examples/LAR_gal_cholesky_example (LAR analytic equilibrium, positive-definite Galerkin
matrix), regression case lar_gal_cholesky, test/runtests_galerkin_cholesky.jl, and a solver note
in docs/src/galerkin.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the feature New capability label Oct 7, 2026

@matt-pharr matt-pharr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple cleanup items and one main question, being whether we should do away with the flag altogether and just make every galerkin solve try to use cholesky first and then use LU as a fallback. See the relevant comment.

Comment thread examples/LAR_gal_cholesky_example/gpec.toml Outdated
Comment thread regression-harness/cases/lar_gal_cholesky.toml Outdated
Comment thread src/ForceFreeStates/Galerkin/GalerkinSolve.jl Outdated
Comment thread src/ForceFreeStates/Galerkin/GalerkinStructs.jl Outdated
Comment thread test/runtests_galerkin_cholesky.jl Outdated
StuartBenjamin and others added 2 commits October 12, 2026 19:35
…rst and fall back to LU

gal_solver defaults to "auto", following @matt-pharr's review recommendation
on #513: the full-band Galerkin matrix is solved by banded Hermitian Cholesky
when it is Hermitian positive definite (Dirichlet rows' columns moved to the
right-hand side), else by the Jacobi-scaled LU. "LU" skips the attempt;
"cholesky" errors instead of falling back. The matrix is always assembled
full band, so the lower-band assembly branches are gone. The test now checks
the solver choice and the solution against a dense solve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…try by entry, diagonal included

The Hermiticity check before the banded Cholesky skipped the diagonal and used one tolerance,
1e-12 of the largest entry. zpbtrf reads only the real part of the diagonal, so a complex diagonal
(the resonant elements' conj(u)·Lu) was dropped silently; and since the diagonal spans many decades,
the small rows passed any asymmetry. Each entry is now compared with 1e-10·√|aᵢᵢaⱼⱼ|, diagonal
included (the quadrature round-off is ~1e-11 of that scale). gal_cholesky_solve! requires kl == ku and solves in place of sol once zpbtrf succeeds.
Galerkin() now defaults to solver = "auto" like gal_solver, and its docstring lists "auto".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…holesky falls back to LU only when not positive definite

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@StuartBenjamin StuartBenjamin changed the title ForceFreeStates.Galerkin - FEATURE - Add the banded Cholesky solve for gal_solver = "cholesky" ForceFreeStates.Galerkin - FEATURE! - Solve the Galerkin system by banded Cholesky, with LU fallback Oct 13, 2026
@github-actions github-actions Bot added the changed-results Results move or an interface breaks - read before upgrading label Oct 13, 2026
@StuartBenjamin
StuartBenjamin marked this pull request as ready for review October 13, 2026 09:52
@matt-pharr matt-pharr added this to the GPEC v2.0.0 milestone Oct 16, 2026
@matt-pharr
matt-pharr added this pull request to stack #533 October 16, 2026 15:17
jhalpern30 and others added 3 commits October 16, 2026 17:21
…sonant surfaces

Resonant powers are the eigenvalues of each side's own M₀ block. √(−det) matches those eigenvalues only when the trace vanishes, and that path is unchanged. alpha_override and extended_precision_bvp are removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
… bitwise-regression for simplicity and correctness
@matt-pharr
matt-pharr removed this pull request from stack #533 October 16, 2026 15:25

@d-burg d-burg left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#517 was merged into this branch, so I'm reviewing its exponent change here, where it will reach develop.

The exponent change is correct, and it is the paper's definition rather than a departure from it, so that's good. Glasser (2016) defines the powers by det[M₀ − αI] = 0 (Eq. 46). Of α = ±√(−D_I) (Eqs. 48–49) the paper says it could not be proved analytically, only that the roots converge to it as Fourier components are added, and it proposes that agreement as a Fourier-convergence check. So sqrt(−det) was Eq. 46 under an unstated tr = 0 assumption.

From claude: regarding @matt-pharr's question (more right, or only more self-consistent): as far as I can derive it, the resonant block has the form [−κ+σ/2 φ; γ conj(κ)−σ/2] with φ, γ real, so tr = −2i·Im κ. That is a genuine term of the truncated operator, not an assembly slip. Matching to a basis with any other powers makes Δ′ depend on the matching distance, so this is more right for the truncated system. It does hide one thing: the old imaginary Δ′ was the only visible symptom of a non-converged resonant block, so I'd like the trace itself reported. Log Im tr / 2 and α per surface (ideally also next to alpha in the HDF5 output), and warn when |tr| is not small against α. The debug print in Asymptotics.jl (lines 125–126, outside this diff) still prints di next to an α that is no longer √(−di); something like:

        block_det = m0mat[1, 1]*m0mat[2, 2] - m0mat[2, 1]*m0mat[1, 2]
        block_tr = m0mat[1, 1] + m0mat[2, 2]
        msg *= @sprintf("  det= %+.12e, tr= %+.12e %+.12ei\n", real(block_det), real(block_tr), imag(block_tr))
        msg *= @sprintf("  alpha= %+.12e %+.12ei\n", real(alpha[1]), imag(alpha[1]))

Requests before this goes to develop:

  1. Release note. This PR now carries #517's results-moving change and the removal of extended_precision_bvp, but the release note covers only gal_solver. Suggested Migration: "To reproduce the previous Galerkin results, set gal_solver = "LU". Remove extended_precision_bvp from [ForceFreeStates] (the TOML key is ignored with a warning; passing it as a keyword to solve is now an error); the Δ′ BVP always solves in Float64. Anything pinned to imaginary Δ′ or to Galerkin alpha must be re-baselined." The Numerical-impact line needs the exponent change too.
  2. CI is red at test/runtests_eulerlagrange.jl:706: the opposed-powers testset still checks bitwise a branch that was removed (suggestion inline).
  3. Harness at the current head. The stamp here (cd290c659) predates the #517 merge, and #517's own report was against a merge-base older than the axis-start change (#514). The near-axis surface is the one most exposed to that.
  4. Wording: "Hermitian with a real diagonal" rather than "Δ′ must be real", as Matt suggested on #517.
  5. Solovev. Solovev is up-down symmetric, so its blocks should be traceless and the exponent change should not move it, yet #517 reported Im Δ′ going from −1.8e9 to −27 there. What is Im tr at its q = 2 surface? If it is round-off level, that change needs another explanation; if not, the matrices have an imaginary part on a symmetric equilibrium.
  6. Near-axis surface at n = 4. A residual Im Δ′ of −25 (about 1 % of the diagonal) means the series and the integrated solution disagree by about 1 % there; I'd treat that as the error bar. A cheap diagnostic: run with JULIA_DEBUG=GeneralizedPerturbedEquilibrium, compare Im tr, α and dpsi^(−2α) at q = 1.25 with the other surfaces, then a three-point singfac_min scan (3e-4, 1e-4, 3e-5) to see whether Δ′₁₁ plateaus.
  7. Small: using DoubleFloats in src/ForceFreeStates/ForceFreeStates.jl (line 22) is now dead; GGJ has its own using.

Comment thread test/runtests_eulerlagrange.jl Outdated
Comment on lines +695 to +708
@testset "column scaling stays /dpsi^α when the powers are opposed" begin
α = ComplexF64(1.5)
dpsi = 1.3e-4
big = ones(ComplexF64, 2, 2)
small = ones(ComplexF64, 2, 2)
FFS._apply_column_powers!(big, small, -α, α, dpsi)
pfac = dpsi^α
big_ref = ones(ComplexF64, 2, 2)
small_ref = ones(ComplexF64, 2, 2)
big_ref ./= pfac
small_ref .*= pfac
@test big == big_ref
@test small == small_ref

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The opposed-powers branch this testset pinned is gone, so its bitwise check now fails CI (line 706). The second half of the testset already checks the general path exactly, so I'd drop the first half rather than relax it to ≈.

Suggested change
@testset "column scaling stays /dpsi^α when the powers are opposed" begin
α = ComplexF64(1.5)
dpsi = 1.3e-4
big = ones(ComplexF64, 2, 2)
small = ones(ComplexF64, 2, 2)
FFS._apply_column_powers!(big, small, -α, α, dpsi)
pfac = dpsi^α
big_ref = ones(ComplexF64, 2, 2)
small_ref = ones(ComplexF64, 2, 2)
big_ref ./= pfac
small_ref .*= pfac
@test big == big_ref
@test small == small_ref
@testset "column scaling multiplies each column by dpsi^power" begin
dpsi = 1.3e-4

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a968945: the opposed-powers half is dropped, and the general half stays.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed in the final head: Stuart removed the obsolete opposed-powers half, while retaining the general column-scaling assertions. We kept that change through the develop merges. See the current test. The final post-merge regression run also completed successfully: 21 cases at three refs, all 63 runs.

function resonant_block_exponents(m0::AbstractMatrix, sig::Real)
tr = m0[1, 1] + m0[2, 2]
det = m0[1, 1] * m0[2, 2] - m0[2, 1] * m0[1, 2]
α = sqrt(tr * tr / 4 - det)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the traceless branch removed this is the only path, so a real block with complex roots throws DomainError and one with real roots returns Float64. Converting keeps it total and type-stable without changing any complex result.

Suggested change
α = sqrt(tr * tr / 4 - det)
α = sqrt(ComplexF64(tr * tr / 4 - det))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: α = sqrt(ComplexF64(tr * tr / 4 - det)).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retained Stuart’s fix: resonant_block_exponents computes sqrt(ComplexF64(tr * tr / 4 - det)), including for real inputs with complex roots. See the helper.

function resonant_block_exponents(m0::AbstractMatrix, sig::Real)
tr = m0[1, 1] + m0[2, 2]
det = m0[1, 1] * m0[2, 2] - m0[2, 1] * m0[1, 2]
α = sqrt(tr * tr / 4 - det)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When tr²/4 − det is negative (a Mercier-unstable surface), it is real by construction, so the sign of the root comes from the sign of a zero or round-off imaginary part. Left and right can then label opposite solutions "big"; a standalone copy of this helper does that for a real block and its mirror. The old shared α made the pairing consistent by construction, and it is now gone entirely. Could α be canonicalised the same way on both sides (Re α > 0, or Im α > 0 when Re α is zero to round-off), or the pair computed once per surface and passed to both sides?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a968945 and 2c02244. When Re α is round-off, α is taken with Im α > 0, so a Mercier-unstable surface gets the same α, and the same big root, on both sides. A test puts ±1e-17i in the radicand.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retained the canonicalization: when the real part of alpha is roundoff-sized, we choose nonnegative Im(alpha), and use the direction-aware tie-breaking for the big/small roots. The Mercier-unstable test perturbs the radicand’s imaginary sign and checks that alpha and the big-root choice remain consistent. See the tests. Each side still uses its own block’s eigenvalue powers; the old alpha_override is not restored.

end
end

@testset "resonant block Frobenius exponents" begin

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests pin the 2×2 algebra but not the claim. The assembled block always has a purely imaginary trace, which none of the inputs here has, and the unit-test fixtures are up-down symmetric, so CI never exercises the new path. I'd add a case with the real block structure (before the column-scaling testset):

    @testset "Hermitian-structured block: shared imaginary shift, real eigenvectors, same powers on both sides" begin
        # Shape the assembly produces: [−κ+σ/2 φ; γ conj(κ)−σ/2] with φ, γ real and κ → −κ on the left side.
        κ, φ, γ = 0.3 + 0.02im, 0.7, 2.5
        m_right = ComplexF64[-κ+0.5 φ; γ conj(κ)-0.5]
        m_left = ComplexF64[κ-0.5 φ; γ -conj(κ)+0.5]
        α_right, big_right, small_right = FFS.resonant_block_exponents(m_right, 1.0)
        α_left, big_left, small_left = FFS.resonant_block_exponents(m_left, -1.0)
        @test α_right ≈ sqrt((real(κ) - 0.5)^2 + φ * γ)
        @test α_left ≈ α_right
        @test imag(big_right) ≈ -imag(κ)
        @test imag(small_right) ≈ -imag(κ)
        @test big_left ≈ big_right
        @test small_left ≈ small_right
        # ξ′ entries of both leading eigenvectors are real, so the big/small cross-Wronskian 2α/φ is real.
        @test abs(imag(-(m_right[1, 1] - big_right) / φ)) < 1e-14
        @test abs(imag(-(m_right[1, 1] - small_right) / φ)) < 1e-14
        @test -(m_right[1, 1] - small_right) / φ + (m_right[1, 1] - big_right) / φ ≈ 2 * α_right / φ
    end

Ideally also one integration-level assertion on an asymmetric fixture: left and right powers agree, and the Riccati Δ′ diagonal is real to a stated tolerance.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added as you wrote it, before the column-scaling testset (a968945).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stuart added the Hermitian-structured block test you suggested, and it remains in the final head: shared imaginary shift, left/right powers, real leading eigenvector components and the cross-Wronskian check. See the test. Your additional ideal integration-level assertion on an asymmetric fixture was not added as a new strict convergence guarantee. Instead we ran the asymmetric DIII-D LU/Cholesky/Riccati comparison across resolutions and matching controls; it exposes a residual Galerkin–Riccati discrepancy rather than supporting a claim of complete agreement. That limitation and the further accuracy investigation are recorded in #603 and the PR body.

M_solve = Tc.(M)

M_lu = lu(M_solve; check=false)
M_lu = lu(M; check=false)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the evidence supports deleting extended_precision_bvp before this reaches develop, only changing its default. It covers only singfac_min = 1e-4; #517's own 2 % miss at singfac_min = 1e-6 (Solovev q₀ = 1.85) is a case where Float64 is measurably wrong, and the code can no longer detect or repair it. A singfac_min scan is also exactly how one would verify the new exponents. The exponent fix and the precision of this LU solve are separate error sources. Could we keep the option as opt-in (default false, which keeps the whole speedup), or replace it with one step of iterative refinement (Float64 LU, residual in Double64) plus a warning when the correction to the read-off entries is large? What do you think?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. It is back as an opt-in, default false (2c02244), so the speedup stays and a singfac_min scan can still check Float64 (point 6 is that case). A test checks that the Double64 solve matches the Float64 Δ′ diagonal on the DIII-D BVP case.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that the exponent correction and BVP precision are separate. We retained extended_precision_bvp and restored the prior true default rather than shipping a default precision reduction here. Explicit false remains available. The no-control path also retains Double64. We added full complex-matrix and componentwise Float64/Double64 checks, plus an independent 256-bit reference for the no-control helper. No iterative-refinement replacement or default precision flip is included in this PR.

StuartBenjamin and others added 3 commits October 16, 2026 22:07
…surface; trace in the debug print; review tests

- resonant_block_exponents: radicand in ComplexF64; a round-off imaginary part is dropped, so a negative
  tr²/4 − det gives the same α (and big root) on both sides.
- Debug print: det, tr and α instead of di.
- Tests: column-scaling testset keeps only its general half (the opposed-powers branch is gone);
  Hermitian-structured block; Mercier-unstable block with ±1e-17i in the radicand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…impler α canonicalisation

- extended_precision_bvp (default false) solves the Δ' BVP and its PEST3 combination in
  Complex{Double64}, to check Float64 at small singfac_min; it is no longer a dropped key.
- resonant_block_exponents: when Re α is round-off, Im α > 0 (same α on both sides).
- DIII-D BVP test: the Double64 solve matches the Float64 Δ' diagonal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t small against α

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@StuartBenjamin

Copy link
Copy Markdown
Collaborator Author

Thanks for the careful review. Most of it concerns #517, which is merged into this branch, so I've answered those points here too. Pushed c827d4d (three commits on top of 4b44dcd). Point by point:

  1. Release note: updated in the PR body. It now covers ForceFreeStates - BUGFIX! - Use trace-aware Frobenius exponents at resonant surfaces #517's exponent change and extended_precision_bvp.
  2. CI: the opposed-powers half of the column-scaling testset is gone, as you suggested. The general half stays.
  3. Harness at the head against develop (regress --refs 278057d96,c827d4d98, all 18 cases, julia 1.11.7, pinned manifest, 2 threads):
Case What moved
gal_resistive_diiid Im Δ′ at q = 5: −15182 → 24.9, so the Δ′ matrix norm goes 12442 → 2183. Re diagonal ≤ 6e-4 relative (−2700.25 → −2698.58 at q = 5)
diiid_n1_riccati (and _precompiled) Im Δ′ at q = 5: 286 → 5e-6. Re diagonal ≤ 1e-7 relative
diiid_n1, diiid_multi_n, diiid_truncation_n1, diiid_error_field energies, PE and torques ≤ 1e-6 relative; island widths and Chirikov ≤ 0.02 %; ODE steps −6 of 1431
solovev_n1, solovev_multi_n, solovev_kinetic_* Re energies ≤ 2e-5 relative (Im parts ~1e-5 absolute); NTV ≤ 0.06 %
diiid_slayer_n1 Q, ω, γ ≤ 0.17 %
the rest unchanged; gal_resistive_pe is N/A on both, as on develop

My three commits on top of 4b44dcd change none of these numbers. With regress --refs 278057d96,4b44dcd02,c827d4d98, every case reports c827d4d unchanged against 4b44dcd: 17 cases run, and gal_resistive_pe is N/A on all three refs.

  1. Wording: the release note now says "Hermitian with a real diagonal".
  2. Solov'ev q₀ = 1.85, n = 1. Im tr/2 is 3.8e-6 at q = 2 (α = 1.89) and −6.2e-6 at q = 3 (α = 0.96), so it is not round-off: the M₀ blocks carry an imaginary part of about 1e-6 of their entries on this up-down-symmetric equilibrium.
    • It does not shrink with mtheta: 3.8e-6, 3.2e-6, 3.3e-6 at q = 2 for mtheta 256, 512, 1024.
    • It changes sign with mpsi (128 → 256: −7e-7 at q = 2, −4.2e-5 at q = 3), so it looks like radial-spline noise in the M₀ entries.
    • An imaginary power of that size, amplified by dpsi^(−2α) with α = 1.89, accounts for the old −1.8e9.
  3. DIII-D-like n = 4 (riccati Δ′ example, nn = 4):
    • At q = 1.25, Im tr/2 ÷ α is 2.6e-6, inside the range of the other 19 surfaces (5e-8 to 3e-5), so the trace is not what is special there.
    • Its block has |M₀₁₁| = 27.6 against α = 0.83 (1 to 4 elsewhere), so α comes out of a large cancellation in det.
    • singfac_min scan (3e-4, 1e-4, 3e-5): Δ′₁₁ = −1196.3, −1279.3, −1272.1, a plateau to 0.6 % below 1e-4. Δ′₁₂ is stable to 0.2 %. Δ′₂₁ = −2.92, −0.20, −4.55, not converged.
    • The other diagonals move ≤ 1.5e-3. Their Im grows from 1e-7 to 5e-4 as singfac_min falls, which is Float64 losing digits at small matching distance.
    • This deck gives |Δ′₁₁| ≈ 1270 and Im ≈ −1.5 at the head, not the 2300 and −25 quoted in ForceFreeStates - BUGFIX! - Use trace-aware Frobenius exponents at resonant surfaces #517.
  4. using DoubleFloats in ForceFreeStates.jl stays, because the restored opt-in path uses it.

You also asked for the trace to be reported. The debug print now shows det, tr and α. A warning fires when a resonant block's |tr|/2 exceeds 1e-3·|α|. On the n = 4 and Solov'ev decks above the ratio is at most 3e-5, so it stays quiet there.

@StuartBenjamin
StuartBenjamin requested a review from d-burg October 17, 2026 21:39
@StuartBenjamin

StuartBenjamin commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

Just human stuart adding here: @jhalpern30 most of these comments refer to ur original PR, I tried my best to respond to d-burg's review, but feel free to check out the responses if you want

StuartBenjamin added a commit to StuartBenjamin/GPEC-julia that referenced this pull request Oct 18, 2026
@matt-pharr

matt-pharr commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

@d-burg @StuartBenjamin Following up on point 5 and on what τ = Im tr(M₀)/2 actually is. I ran a few more tests on feyn. In short: τ is numerical, it has two identifiable sources, and both go away with resolution. The eigenvalue exponents are still the right choice.

What τ is. In every run, τ equals Im[(F̄⁻¹K̄)ᵣᵣ]/(n q′) at the rational surface (r = the resonant mode) to within 1e-9. That combination is large away from the surface and should pass exactly through zero at ψₛ, because two terms cancel there: the resonant term (F̄⁻¹)ᵣᵣ·Im K̄ᵣᵣ and the sum over the other modes. τ is whatever is left over from that cancellation.

It isn't geometry. Mirroring the DIII-D-like g-file in Z does not flip the sign of τ (the mirror/original ratios are +0.2 to +3.2), and the up-down symmetric Solovev has the largest τ of the cases I ran. The off-diagonal Δ′ phases, on the other hand, conjugate exactly under the mirror (e.g. Δ′₁₂ −16.57° → +16.57°). So the complex off-diagonals are genuine geometry: Δ′ is Hermitian, and it is real only for up-down symmetric equilibria.

Where the leftover comes from (DIII-D-like deck):

  1. Radial interpolation. L̄ and K̄ are splined separately in ψ, and between knots their combination isn't exactly zero at ψₛ. Putting ψₛ exactly on a grid knot cuts τ by 100–10,000× (e.g. q=3: 1.4e-7 → 1.6e-9 at mpsi = 512). This part shrinks with mpsi, but unevenly, depending on where ψₛ falls within its cell.
  2. Poloidal mode truncation. Even with ψₛ on a knot, a small residue remains, largest near the edge: at q=6 it is 2.7e-7 relative to Re(F̄⁻¹K̄)ᵣᵣ with delta_m = 8. Widening the mode band removes it. At q=6 it drops about 1000× at delta_m = 16 and reaches about 1e-11 at 32; at q=2 it reaches round-off by 16.

Neither mtheta (256 to 1024) nor etol (1e-10 to 1e-14) changes either part.

Solovev (point 5). Its τ (1e-6 to 1e-4) wanders and flips sign as mpsi and mtheta change, and the Mercier D_I itself drifts with the Solovev grid. So the floor comes from the analytic Solovev equilibrium rather than from the ψ/θ grids. Amplified near the pole, that gives the old −1.8e9, which fits Stuart's numbers.

What it means for this PR. At any finite resolution the discretized operator really does have this small trace. The eigenvalue exponents keep the asymptotic series consistent with it, so Δ′ no longer depends on the matching distance. Forcing tr = 0 would undo that. Nothing suggests Glasser's ±√(−D_I) is a symmetric-only result; it is the converged limit.

On the new warning. τ is only a loose proxy for an under-resolved surface. At mpsi = 128 the q=4 surface has det(M₀) 34 % off the Mercier D_I, yet τ/α ≈ 4e-5, below the 1e-3 threshold. Comparing det(M₀) directly with the Mercier D_I would catch that case. I'd suggest two follow-ups for an issue rather than this PR: that check, and inserting each rational surface into the radial grid as a knot, which removes the interpolation part of τ by construction.

@StuartBenjamin
StuartBenjamin marked this pull request as draft October 19, 2026 07:52
@matt-pharr
matt-pharr marked this pull request as ready for review October 26, 2026 16:27
d-burg
d-burg previously requested changes Oct 26, 2026

@d-burg d-burg left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @StuartBenjamin, this is a nice cleanup. Moving the Dirichlet columns to the right-hand side is the right way to keep the system Hermitian and still honor the rpec coil sources, checking zpbtrf's info is a real fix, and one full-band matrix for both paths removes a lot of branching. Before it goes in:

  1. Check Hermiticity before using Cholesky. "auto" currently returns a Cholesky answer for any matrix whose lower band is positive definite, even when the full matrix isn't Hermitian, so it can silently solve a different system. Suggestion 1 adds the check (it also catches a missed Dirichlet row), and suggestion 3 is a test that fails on the current head.
  2. Make the solver choice visible. Cholesky and LU differed by 0.40 % on the PEST3 Δ diagonal before the M₀-block fix, so a scan that crosses the positive-definite boundary would jump with nothing in the output to explain it. An @info on fallback (suggestion 2) is the minimum; recording the solver in the Galerkin HDF5 group would be better.
  3. Accuracy at this head. Could you post the Cholesky-vs-LU difference at c827d4d98, plus a comparison that says which is closer to converged (the Riccati Δ′ on the same equilibrium, or a gal_nx refinement)? A 5.6e-5 relative change in the diagonal moved Δ by 0.4 %, so the extraction is sensitive.
  4. Split out extended_precision_bvp. Double64 → Float64 is a precision change unrelated to the Galerkin solver, and it also changes what ctrl === nothing callers get. I'd move it to its own PR with isolated harness numbers (diiid_n1_riccati, solovev_*, a small-singfac_min case), the timing behind "1.5–5.4×", and a test of the imaginary and off-diagonal entries (suggestions 5–7).
  5. Solve-stage timing for Cholesky, LU, and a failed Cholesky followed by LU. On a free-boundary case that is indefinite only in the edge vacuum block, the failed attempt costs close to a full factorization.
  6. Merge develop and re-run the harness. Develop has since changed the Galerkin element sizing and the GGJ ray path in the same files, and still carries alpha_override, so the current report is against a stale base.

Smaller: the release note's item 1 quotes numbers from before the M₀-block change; the kl != ku case reports "not positive definite" for what is a structural mismatch; and the new resonant-trace warning fires per side on every crossing and triggers on any small trace near Mercier-marginal surfaces, with an undocumented 1e-3 threshold.

Comment on lines +393 to +398
fixed = [all(j == i ? a(i, j) == 1 : iszero(a(i, j)) for j in band(i)) for i in 1:n]
ab = zeros(ComplexF64, kl + 1, n)
for j in 1:n, i in j:min(n, j + kl)
ab[1+i-j, j] = fixed[i] || fixed[j] ? (i == j ? one(ComplexF64) : zero(ComplexF64)) : a(i, j)
end
gal_zpbtrf!(ab, kl) || return false

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cholesky reads only the lower band and Re of the diagonal, so a non-Hermitian matrix (or a Dirichlet row the pattern match misses) silently solves a different system. Fall back to LU unless the free block is Hermitian to round-off; the negated <= also sends NaN to LU.

Suggested change
fixed = [all(j == i ? a(i, j) == 1 : iszero(a(i, j)) for j in band(i)) for i in 1:n]
ab = zeros(ComplexF64, kl + 1, n)
for j in 1:n, i in j:min(n, j + kl)
ab[1+i-j, j] = fixed[i] || fixed[j] ? (i == j ? one(ComplexF64) : zero(ComplexF64)) : a(i, j)
end
gal_zpbtrf!(ab, kl) || return false
fixed = [all(j == i ? a(i, j) == 1 : iszero(a(i, j)) for j in band(i)) for i in 1:n]
# Cholesky reads only the lower band: fall back unless the free block is Hermitian to round-off.
for j in 1:n, i in (j+1):min(n, j + kl)
fixed[i] || fixed[j] || abs(a(i, j) - conj(a(j, i))) <= 1e-10 * sqrt(abs(a(i, i)) * abs(a(j, j))) || return false
end
ab = zeros(ComplexF64, kl + 1, n)
for j in 1:n, i in j:min(n, j + kl)
ab[1+i-j, j] = fixed[i] || fixed[j] ? (i == j ? one(ComplexF64) : zero(ComplexF64)) : a(i, j)
end
gal_zpbtrf!(ab, kl) || return false

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented the off-diagonal guard, including finite-entry checks and Dirichlet-row handling, in 633f9bd and retained it in the final head. Entries use the local scale 1e-10 * sqrt(abs(A_ii)) * sqrt(abs(A_jj)); rejected attempts preserve the full matrix/RHS for LU. One deliberate qualification: the diagonal is still projected to its real part, so this is not a full Hermiticity check. That choice is explicit in the docs and a separate imaginary-diagonal test. The coefficient alignment subsequently reduced the observed LU/Cholesky Delta-prime difference to at most 2.53e-9 relative norm across our expanded battery. See implementation and tests.

Comment on lines +370 to +377
function gal_banded_solve!(mat::Matrix{ComplexF64}, sol::AbstractVecOrMat{ComplexF64}, kl::Int, ku::Int, solver::String)
if solver != "LU"
gal_cholesky_solve!(mat, sol, kl, ku) && return "cholesky"
solver == "cholesky" && error("gal_solver=\"cholesky\": the Galerkin matrix is not positive definite; use \"auto\" or \"LU\"")
end
gal_scaled_lu_solve!(mat, sol, kl, ku)
return "LU"
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Report a fallback even without verbose, so a solver switch inside a scan is visible, and make the forced-Cholesky error name both failure modes.

Suggested change
function gal_banded_solve!(mat::Matrix{ComplexF64}, sol::AbstractVecOrMat{ComplexF64}, kl::Int, ku::Int, solver::String)
if solver != "LU"
gal_cholesky_solve!(mat, sol, kl, ku) && return "cholesky"
solver == "cholesky" && error("gal_solver=\"cholesky\": the Galerkin matrix is not positive definite; use \"auto\" or \"LU\"")
end
gal_scaled_lu_solve!(mat, sol, kl, ku)
return "LU"
end
function gal_banded_solve!(mat::Matrix{ComplexF64}, sol::AbstractVecOrMat{ComplexF64}, kl::Int, ku::Int, solver::String)
if solver != "LU"
gal_cholesky_solve!(mat, sol, kl, ku) && return "cholesky"
solver == "cholesky" && error("gal_solver=\"cholesky\": the Galerkin matrix is not Hermitian positive definite; use \"auto\" or \"LU\"")
@info "Galerkin matrix is not Hermitian positive definite; solving by LU"
end
gal_scaled_lu_solve!(mat, sol, kl, ku)
return "LU"
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented: auto fallback emits an info message even with verbose=false, and the forced-Cholesky error names off-diagonal symmetry/real-diagonal positive-definiteness failure. Unequal kl/ku gets its own structural diagnostic. We also went beyond logging: GalerkinResult records the factorization actually used, written to ForceFreeStates/Solutions/GalerkinIntegration/solver. The physical free-boundary timing run verified an actual failed banded Cholesky followed by LU, rather than only rejection by the guard.

Comment on lines +37 to +38
@test_throws ErrorException FFS.gal_banded_solve!(ab, copy(rhs), kl, kl, "cholesky")
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A matrix whose lower band is positive definite but which is not Hermitian must go to LU. This fails on the current head.

Suggested change
@test_throws ErrorException FFS.gal_banded_solve!(ab, copy(rhs), kl, kl, "cholesky")
end
@test_throws ErrorException FFS.gal_banded_solve!(ab, copy(rhs), kl, kl, "cholesky")
# Positive definite lower band but non-Hermitian: Cholesky would solve a different system.
H[5, 5] = 4 + sin(5)
H[6, 4] += 0.1
A, ab = system(H)
sol = copy(rhs)
@test FFS.gal_banded_solve!(ab, sol, kl, kl, "auto") == "LU"
@test sol ≈ A \ rhs rtol = 1e-12
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this regression case: a positive-definite lower band with an asymmetric off-diagonal entry must select LU and agree with the dense solution. We also cover a perturbed Dirichlet row, nonfinite entries, unequal bandwidths, tolerated off-diagonal roundoff, and the explicitly retained imaginary-diagonal projection. See the current solver tests. The focused runs passed 27 solver assertions plus 71 coefficient assertions per example.

Comment on lines +409 to +410
# Lower-band Hermitian Cholesky as rdcon gal.f: zpbtrf('L') then zpbtrs('L'). LinearAlgebra.LAPACK
# has no pbtrf!/pbtrs! wrapper, so call LAPACK directly. Returns false if not positive definite.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New backend comment cites a Fortran file, the repo cites papers only.

Suggested change
# Lower-band Hermitian Cholesky as rdcon gal.f: zpbtrf('L') then zpbtrs('L'). LinearAlgebra.LAPACK
# has no pbtrf!/pbtrs! wrapper, so call LAPACK directly. Returns false if not positive definite.
# Lower-band Hermitian Cholesky (LAPACK zpbtrf/zpbtrs, 'L'). LinearAlgebra.LAPACK has no
# pbtrf!/pbtrs! wrapper, so call LAPACK directly. Returns false if not positive definite.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied the suggested backend comment; it now describes the lower-band LAPACK zpbtrf/zpbtrs wrapper and failure behavior without the Fortran-file reference. See the comment.

intr, fm_propagators, fm_chunks;
wv=vac.wv, psio=equil.psio,
S_at_surface_left=fm_S_left, ctrl=ctrl_ext, equil=equil, mats=mats)
@test isapprox(real.(diag(intr.delta_prime_matrix)), real.(diag(dpm)); rtol=1e-8)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new precision test checks only the real diagonal, the imaginary and off-diagonal Δ′ were the reason for Double64. Compare the whole complex matrix too.

Suggested change
@test isapprox(real.(diag(intr.delta_prime_matrix)), real.(diag(dpm)); rtol=1e-8)
@test isapprox(real.(diag(intr.delta_prime_matrix)), real.(diag(dpm)); rtol=1e-8)
@test isapprox(intr.delta_prime_matrix, dpm; rtol=1e-8)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented the whole-complex-matrix comparison in addition to the real diagonal, and added per-entry real/imaginary checks so a dominant large entry cannot mask every small component. The test explicitly compares the retained default Double64 path with extended_precision_bvp=false. A separate helper test uses an independent 256-bit reference. See the integration comparison and the reference test.

Comment thread src/ForceFreeStates/CoreTypes.jl Outdated
integrator::String = "riccati" # Default: unlocks SingularSurfaces/Delta_prime_matrix (STRIDE BVP Δ′ matrix) used by SLAYER/GGJ downstream. Use "forward" for dense ξ (PerturbedEquilibrium) or kinetic runs.
nchunks::Int = 0 # Riccati chunk-count target; 0 = auto (derived from msing alone, never from Threads.nthreads()).
extended_precision_bvp::Bool = true # Promote Δ' BVP to Complex{Double64}; default on (Float64 drifts the imaginary Δ' by 2–5× on DIIID-class cases).
extended_precision_bvp::Bool = false # Δ' BVP in Complex{Double64}; opt-in check of Float64 at small singfac_min.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep the Double64 default here and move the flip to its own PR with isolated numbers (restore the line-156 docstring with it).

Suggested change
extended_precision_bvp::Bool = false # Δ' BVP in Complex{Double64}; opt-in check of Float64 at small singfac_min.
extended_precision_bvp::Bool = true # Promote Δ' BVP to Complex{Double64}; default on (Float64 drifts the imaginary Δ' by 2–5× on DIIID-class cases).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 633f9bd: extended_precision_bvp=true is restored, together with the default/precision contract in its docstring. This PR does not flip that default; any proposed precision reduction will need separate evidence and review. See the control definition.

end
# Fortran sing_solve: vmat(n1,isol,:,k) *= sig/(power(isol)+k/2)
vmat[n1, isol, :, k+1] .*= sig / (power[isol] + k / 2.0)
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whatever the default, a ctrl === nothing caller should keep getting what it got before.

Suggested change
Tc = (ctrl === nothing || ctrl.extended_precision_bvp) ? Complex{Double64} : ComplexF64

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: the helper chooses Complex{Double64} when ctrl === nothing or the option is true, preserving the old no-control behavior. We also added a no-control test against a 256-bit reference, with an explicit Float64 comparison. The helper now lives in DeltaPrimeBVP.jl.

@logan-nc logan-nc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One structural point on top of today's review, since it is not in the threads: the trace-aware Frobenius exponents (4b44dcd, the former #517) are what moves the numbers here (Im Δ′ at q = 5 from −15182 to 24.9, the 82 % matrix-norm change), they are agreed correct above, and the harness table shows the later review commits change nothing on top of them. The Cholesky items still open (Hermiticity before auto, the timing and accuracy data, the develop re-run) do not touch that commit. Splitting the exponent fix into its own small ForceFreeStates - BUGFIX! - PR with its own harness stamp would land the physics correction now and leave this PR to the solver question; it also unblocks the parity plan in #591 (item A7). At 633f9bd the Hermiticity guard and the visible fallback are in and extended_precision_bvp is back to true, so of the six items only the three data requests and the develop merge remain on the author.

@matt-pharr

Copy link
Copy Markdown
Collaborator

@logan-nc fair points but I think we have discussed and agreed on the fortran side of things that the fallback solver model is best, and we are going to go ahead and implement that now. I will merge this in the next couple of hours and both will go in today.

@matt-pharr

Copy link
Copy Markdown
Collaborator

Summary of how the final branch differs from Stuart's original contribution, and what we added during review/integration:

Stuart's contribution remains the foundation. The original submission (b5b430f57) supplied working banded LAPACK Cholesky wrappers and explicit failure handling for gal_solver="cholesky". Stuart's subsequent revisions added Cholesky-first "auto" with LU fallback, full-band assembly and Dirichlet-column transfer, along with Frobenius follow-ups and diagnostics. Those should not be attributed to the later validation work.

On top of Stuart's latest revision (c827d4d98), the review/integration work adds:

  • Solver safeguards and observability. Restored finite-entry and locally scaled off-diagonal conjugate-symmetry checks before Cholesky; preserved the deliberate real-diagonal projection. The LU matrix/RHS remain available after a failed attempt. Auto fallback is logged even with verbosity disabled, unequal bandwidths receive a structural diagnostic, and the actual solver used is recorded in the Galerkin result and HDF5 output. Tests cover asymmetric/nonfinite systems, boundary handling, roundoff tolerance and fallback behavior.
  • BVP precision and trace diagnostics. Restored extended_precision_bvp=true as the default, including callers without a control object, while retaining explicit Float64 selection. Added whole-complex-matrix and componentwise precision checks and an independent 256-bit reference. Limited the resonant-trace warning to once per surface per solve, with a roundoff floor in its threshold.
  • Consistent ideal coefficients. Galerkin now evaluates F̄=LL† from the same interpolated Cholesky factor used by Riccati and the singular-surface expansions. Previously Galerkin independently interpolated F̄, so the methods solved different between-knot operators. Existing buffers are reused; tests distinguish the representations between knots and check rational surfaces and buffer reuse.
  • Develop integration. Resolved the earlier integration conflicts while preserving both the trace-aware Frobenius correction from ForceFreeStates - BUGFIX! - Use trace-aware Frobenius exponents at resonant surfaces #517 and the solver changes in this PR. The latest develop merge was conflict-free; final head is a1efcd6f7.

What the investigation establishes: matching the coefficients reduces the DIII-D full complex PEST-3 Δ′ discrepancy against Riccati from about 10.98% to 0.152% at nx=256. Across the broader resolution/width/order/quadrature/reference-control battery, LU and Cholesky agree within 2.53e-9 relative Δ′ matrix norm. This supports the coefficient correction and solver consistency; it does not establish Riccati as an exact answer. The residual gap at nx=1024 is about 0.1451% for DIII-D and 0.00207% for LAR. The unresolved accuracy investigation is deferred to #603 by maintainer agreement.

Fallback timing is now measured: on a physical DIII-D free-boundary assembly, Cholesky actually fails and auto falls back to LU. Seven warmed, alternating samples give 0.898 s for auto versus 0.540 s for direct LU (+0.358 s, 66%); resulting Δ′ matrices are identical. LAR's free-boundary matrix accepts Cholesky and auto takes 0.299 s versus 0.322 s for LU. These exclude assembly and input copies. "auto" attempts/falls back, "cholesky" requires success, and "LU" skips the attempt.

Final validation: the post-develop-merge fanout ran all 21 cases at develop, the pre-merge PR head and a1efcd6f7: all 63 runs completed successfully. Relative to the pre-merge PR, 20 cases are unchanged within thresholds; the driven-PE case retains eight N/A quantities. The coefficient change itself materially changes driven quantities: coil-block norm −93.51% and matched bpen norm −67.06% versus b75eeb3a8, in addition to the Δ′ changes. Those impacts are explicitly documented in the PR body and remain part of review; the missing driven-PE quantities limit end-to-end coverage.

The PR body contains the final harness stamp and retains the historical investigation record. Third-party human approval is required before merging into develop.

@matt-pharr matt-pharr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving and merging this to get my big refactoring work unblocked. @d-burg feel free to look at my responses to your requests for changes and make additional comments, I get them in my inbox and can address them in/after #602.

@matt-pharr
matt-pharr requested a review from d-burg October 27, 2026 15:49
@matt-pharr
matt-pharr dismissed d-burg’s stale review October 27, 2026 15:50

Changes addressed

@matt-pharr
matt-pharr enabled auto-merge October 27, 2026 15:50
@matt-pharr
matt-pharr disabled auto-merge October 27, 2026 16:02
@matt-pharr
matt-pharr merged commit 041e65f into develop Oct 27, 2026
37 of 39 checks passed
@matt-pharr
matt-pharr deleted the feature/galerkin-cholesky branch October 27, 2026 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changed-results Results move or an interface breaks - read before upgrading feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants